NULL은 값이 아니라 알 수 없음이다

NULL은 값이 아니라 알 수 없음이다

한눈에 보기

NULL 비교에는 IS NULL을 사용한다. 일반 비교 결과는 true나 false가 아니라 unknown이 될 수 있으며 WHERE는 true인 행만 남긴다.

목차

문제가 되는 상황

배송 완료 시각이 아직 없다는 이유로 0000-00-00이나 빈 문자열을 저장하고, 할인 금액을 알 수 없다는 이유로 0을 저장하면 서로 다른 의미가 섞인다. 0원 할인은 확인된 값이지만 NULL 할인은 아직 계산되지 않았을 수 있다.

SQL의 NULL은 일반 값 하나가 아니라 누락되었거나 알 수 없는 상태를 나타내는 표지다. 비교 결과도 true와 false만이 아니라 unknown이 될 수 있다. 이 차이를 모르면 column = NULL, NOT IN, LEFT JOIN, COUNT와 AVG에서 조용히 행이 빠지거나 잘못된 숫자가 나온다.

이 글의 예제에 관하여

사용자·주문·할인 데이터는 NULL 동작을 설명하기 위한 가상 값이다. 실제 사용자 정보를 사용하지 않았다.

NULL과 빈 문자열, 0은 다르다

다음 값은 서로 다른 도메인 상태다.

저장 값 가능한 의미
nickname = NULL 아직 설정하지 않았거나 알 수 없음
nickname = '' 사용자가 명시적으로 빈 문자열을 입력함
discount = 0 할인이 없다고 확정됨
discount = NULL 아직 할인 계산 전 또는 정보 없음

스키마는 의미에 맞게 NULL 가능성을 정한다.

CREATE TABLE orders (
  id BIGINT PRIMARY KEY,
  subtotal_minor BIGINT NOT NULL,
  discount_minor BIGINT NULL,
  discount_calculated_at DATETIME NULL
);

NOT NULL DEFAULT 0을 습관적으로 붙이면 “계산 전” 상태를 표현할 수 없게 된다. 반대로 반드시 존재해야 하는 주문 통화까지 nullable로 만들면 모든 query와 코드에 불필요한 분기가 퍼진다.

SQL의 삼값 논리

NULL이 포함된 일반 비교는 unknown이 될 수 있다.

표현 결과
5 = 5 TRUE
5 = 7 FALSE
5 = NULL UNKNOWN
NULL = NULL UNKNOWN

WHERE는 결과가 TRUE인 행만 남긴다. FALSE뿐 아니라 UNKNOWN도 제거한다.

SELECT id, discount_minor
FROM orders
WHERE discount_minor > 0;

이 query에서 discount가 NULL인 행은 NULL > 0이 unknown이므로 결과에 포함되지 않는다.

AND와 OR도 unknown을 전파한다.

A B A AND B A OR B
TRUE UNKNOWN UNKNOWN TRUE
FALSE UNKNOWN FALSE UNKNOWN
UNKNOWN UNKNOWN UNKNOWN UNKNOWN

조건을 논리식만 보고 단순 변환할 때 NULL이 가능한 컬럼인지 확인해야 한다.

NULL 비교에는 IS NULL을 쓴다

다음 조건은 참이 되지 않는다.

SELECT *
FROM users
WHERE deleted_at = NULL;

NULL 여부를 확인하는 전용 predicate를 사용한다.

SELECT *
FROM users
WHERE deleted_at IS NULL;
SELECT *
FROM users
WHERE deleted_at IS NOT NULL;

두 nullable 값이 같다고 볼 때 “둘 다 NULL이면 같음”까지 포함하려면 DB가 제공하는 null-safe equality 연산을 확인한다. MySQL의 <=>, 표준적인 IS NOT DISTINCT FROM 지원 여부처럼 DB별 문법이 다를 수 있다.

-- MySQL의 null-safe equality 예
SELECT *
FROM snapshots
WHERE previous_value <=> current_value;

애플리케이션에서 동적으로 column = ?에 null을 bind한다고 자동으로 IS NULL이 되는지 query builder 동작을 확인한다.

NOT과 NOT IN에서 생기는 함정

NOT (discount_minor > 0)이 NULL인 행을 포함한다고 생각하기 쉽지만, NOT UNKNOWN도 UNKNOWN이다.

-- 0 이하인 확정 값만 포함하며 NULL은 포함하지 않는다.
WHERE NOT (discount_minor > 0)

NULL까지 포함하려면 의도를 명시한다.

WHERE discount_minor <= 0
   OR discount_minor IS NULL

NOT IN의 목록에 NULL이 있으면 더 큰 함정이 생긴다.

SELECT id
FROM users
WHERE id NOT IN (
  SELECT banned_user_id
  FROM bans
);

bans.banned_user_id에 NULL이 하나 있으면 각 id가 목록의 모든 값과 다르다고 true로 확정할 수 없어 결과가 비어 보일 수 있다. 관계 부재를 찾을 때는 NOT EXISTS가 안전하고 의도가 명확하다.

SELECT u.id
FROM users u
WHERE NOT EXISTS (
  SELECT 1
  FROM bans b
  WHERE b.banned_user_id = u.id
);

JOIN에서 NULL이 만들어지는 경우

LEFT JOIN은 오른쪽에 일치 행이 없으면 오른쪽 컬럼을 NULL로 채운다. 원래 profiles.nickname이 NOT NULL이어도 join 결과에서는 nullable이다.

SELECT u.id, p.nickname
FROM users u
LEFT JOIN profiles p ON p.user_id = u.id;

없는 profile을 찾을 때 nullable 업무 컬럼보다 오른쪽 non-null key를 검사한다.

WHERE p.user_id IS NULL

p.nickname IS NULL을 사용하면 “profile 없음”과 “profile은 있지만 nickname이 NULL”을 구분하지 못할 수 있다.

JOIN key 자체가 NULL이면 일반 equality로 서로 일치하지 않는다.

ON a.optional_code = b.optional_code

양쪽 코드가 모두 NULL이어도 하나의 같은 코드로 join되지 않는다. NULL을 “기타 그룹”처럼 동일 값으로 취급하고 싶다면 그 의미가 맞는지 검토한 뒤 명시적으로 처리한다.

COUNT, SUM, AVG가 NULL을 다루는 방식

집계 함수는 서로 다른 방식으로 NULL을 처리한다.

SELECT
  COUNT(*) AS row_count,
  COUNT(discount_minor) AS known_discount_count,
  SUM(discount_minor) AS discount_sum,
  AVG(discount_minor) AS known_discount_average
FROM orders;

다음 데이터에서 AVG는 (0 + 1000) / 2 = 500이다. NULL을 0으로 포함한 / 3이 아니다.

discount_minor
0
1000
NULL

“알 수 없는 할인은 평균에서 제외”가 맞는지, “미계산 주문 때문에 평균을 보여 주면 안 됨”이 맞는지 제품 의미를 정한다.

COALESCE는 표시와 계산 의미를 바꾼다

COALESCE는 첫 번째 NULL이 아닌 값을 반환한다.

SELECT COALESCE(nickname, '이름 없음') AS display_name
FROM profiles;

UI 표시 기본값에는 유용하다. 하지만 계산에서 NULL을 0으로 바꾸면 통계 의미가 달라진다.

AVG(COALESCE(discount_minor, 0))

이제 미계산 할인도 0원으로 평균에 들어간다. 그것이 도메인 규칙이 아니라면 잘못된 통계다.

predicate에서 컬럼을 COALESCE하면 index 사용에도 영향을 줄 수 있다.

WHERE COALESCE(deleted_at, '9999-12-31') > CURRENT_TIMESTAMP

명확한 NULL 분기와 적절한 index 또는 generated column을 검토한다. 편의를 위해 의미와 실행 계획을 동시에 숨기지 않는다.

UNIQUE 제약과 NULL

여러 DB에서는 UNIQUE 컬럼에 NULL을 여러 개 허용할 수 있다. NULL끼리 같다고 판단하지 않기 때문이다. DB와 index 종류에 따라 semantics가 다르므로 실제 엔진을 확인한다.

CREATE TABLE user_profiles (
  user_id BIGINT PRIMARY KEY,
  external_handle VARCHAR(80) NULL,
  UNIQUE KEY uq_external_handle (external_handle)
);

handle이 있는 사용자끼리는 중복을 막고 미설정 NULL은 여러 행에 있을 수 있다. “NULL도 최대 하나만” 같은 규칙이 필요하다면 별도 check·generated key·partial index 또는 schema 모델링이 필요하다.

복합 unique와 NULL의 조합도 예상과 다를 수 있다.

UNIQUE (tenant_id, external_code, deleted_at)

soft delete 활성 행의 deleted_at = NULL 중복을 이것만으로 막을 수 있다고 가정하지 않는다.

도메인 상태를 NULL 하나로 뭉치지 않는다

NULL 하나가 다음 세 상태를 모두 뜻하면 query와 UI가 구분할 수 없다.

필요하면 상태 컬럼과 값을 함께 모델링한다.

CREATE TABLE risk_assessments (
  order_id BIGINT PRIMARY KEY,
  status VARCHAR(20) NOT NULL,
  score DECIMAL(5,2) NULL,
  CHECK (
    (status = 'completed' AND score IS NOT NULL)
    OR
    (status IN ('pending', 'not-applicable', 'failed') AND score IS NULL)
  )
);

판별 상태가 있으면 “score NULL”의 이유를 알고 재시도·UI 표시를 다르게 할 수 있다.

애플리케이션 타입과 API 계약

DB nullable은 애플리케이션 타입에도 반영한다.

type Order = {
  id: string;
  discountMinor: number | null;
  discountStatus: "pending" | "calculated" | "not-applicable";
};

TypeScript optional field?: number는 필드가 없을 수 있음을 뜻하고 field: number | null은 필드가 존재하되 null일 수 있음을 뜻한다. JSON API에서 누락과 null의 의미를 OpenAPI에 정확히 표현한다.

{
  "discountMinor": null,
  "discountStatus": "pending"
}

ORM이 SQL NULL을 언어의 undefined, null, zero value 중 무엇으로 바꾸는지 확인한다. 저장 시 undefined를 “변경하지 않음”, null을 “값 제거”로 해석하는 PATCH API도 명시적인 계약이 필요하다.

실전 점검 목록

NULL 모델링

  • NULL, 빈 문자열, 0의 도메인 의미를 구분했는가?
  • = NULL 대신 IS NULL을 사용하는가?
  • NOT과 NOT IN에서 unknown이 생길 수 있는가?
  • LEFT JOIN에서 오른쪽 non-null key로 관계 부재를 검사하는가?
  • COUNT(column), SUM, AVG가 NULL을 제외하는 의미가 맞는가?
  • COALESCE가 통계와 index 의미를 바꾸지 않는가?
  • DB의 UNIQUE와 NULL semantics를 실제로 확인했는가?
  • 미입력·해당 없음·실패를 상태 컬럼으로 분리할 필요가 있는가?
  • API의 optional과 nullable을 구분했는가?

NULL 비교에는 IS NULL을 사용한다. 일반 비교 결과는 true나 false가 아니라 unknown이 될 수 있으며 WHERE는 true인 행만 남긴다.

결론

NULL은 빈 문자열이나 0 같은 값이 아니라 누락되었거나 알 수 없는 상태를 나타내며 SQL 비교를 UNKNOWN으로 만들 수 있다. IS NULL, NOT EXISTS, NULL을 제외하는 집계 semantics를 이해하고 COALESCE로 의미를 무심코 바꾸지 않는다. 도메인에 미입력·해당 없음·처리 실패가 따로 필요하다면 상태 컬럼으로 분리하고 애플리케이션과 API의 nullable 계약까지 일치시켜야 한다.

관련 노트